Passed
Push — main ( 49c5e3...e33a00 )
by Eduardo
02:11
created

SoFloC.ts ➔ getXmlContentFromZip   A

Complexity

Conditions 1

Size

Total Lines 1
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 1
CRAP Score 1

Importance

Changes 0
Metric Value
eloc 1
dl 0
loc 1
ccs 1
cts 1
cp 1
rs 10
c 0
b 0
f 0
cc 1
crap 1
1 1
import { randomUUID } from 'crypto'
2 1
import JSZip from 'jszip'
3 1
import { xml2js } from 'xml-js'
4
import { CustomisationsXml } from './customisations'
5
import { SolutionXml } from './solution'
6
import { Base64, FileInput, FlowCopyT, PrivateWorkflowT, WorkflowT, Xml } from './types'
7
8 1
export * from './types'
9
10 1
export class SoFloC {
11
  /**
12
   * Creates a new SoFloC instance. To be able to use it you need to run `await soFloC.load()`
13
   * @param file The file data to be open
14
   * @param name The name of the file
15
   */
16 12
  constructor (file: FileInput, name: string) {
17 12
    this.#wasLoaded = false
18 12
    this.#file = file
19 12
    this.name = name
20
  }
21
22
  /**
23
   * Loads a ***Solution*** zip file and make it ready to get the existing flows and the version, copy flows and update the version. Sets #wasLoaded to true
24
   */
25 14
  async load () {
26 14
    if (!this.#wasLoaded) {
27 11
      try {
28 11
        this.#zip = await this.#unzip(this.#file)
29
30 10
        const [customisations, customisationsData] = await this.#getCustomisations(this.#zip)
31 9
        this.#customisations = customisations
32 9
        this.#customisationsData = customisationsData
33
34 9
        const [solution, solutionData] = await this.#getSolution(this.#zip)
35 8
        this.#solution = solution
36 8
        this.#solutionData = solutionData
37
38 8
        this.version = this.#getCurrentVersion(this.#solutionData)
39 7
        this.#workflows = this.#getWorkflows(this.#customisationsData, this.#solutionData, this.#zip)
40 7
        this.data = await this.#getData(this.#zip)
41
42 7
        this.#wasLoaded = true
43
      } catch (error) {
44 4
        if (typeof error === 'string') {
45 4
          throw new Error(error)
46
        }
47
        /* istanbul ignore next */
48
        throw error
49
      }
50
    }
51
  }
52
53
  /**
54
   * Copies a flow in the ***Solution***.
55
   * @param flowGuid The GUID of the flow to be copied
56
   * @param newFlowName The name of the copy
57
   * @param newVersion The new ***Solution*** version
58
   */
59 7
  async copyFlow (flowGuid: string, newFlowName: string, newVersion?: string) {
60 7
    await this.load()
61 6
    try {
62 6
      this.#worflowExists(flowGuid)
63
64 3
      if (newVersion) await this.updateVersion(newVersion)
65
66 3
      const copyData = this.#getCopyData(newFlowName)
67
68 3
      const [customisations, customisationsData] = this.#getUpdatedCustomisations(flowGuid, copyData)
69 3
      this.#customisations = customisations
70 3
      this.#customisationsData = customisationsData
71
72 3
      const [solution, solutionData] = this.#getUpdateSolution(flowGuid, copyData)
73 3
      this.#solution = solution
74 3
      this.#solutionData = solutionData
75
76 3
      await this.#copyFile(flowGuid, copyData)
77
    } catch (error) {
78 3
      if (typeof error === 'string') {
79 3
        throw new Error(error)
80
      }
81
      /* istanbul ignore next */
82
      throw error
83
    }
84
  }
85
86
  /**
87
   * Updates the ***Solution*** version. The new version must be bigger than the previous.
88
   * @param newVersion The new ***Solution*** version
89
   */
90 7
  async updateVersion (newVersion: string) {
91 7
    await this.load()
92 4
    try {
93 4
      this.#validateVersion(newVersion)
94
95 2
      this.name = this.name
96
        .replace(this.#snake(this.version), this.#snake(newVersion))
97 2
      this.#solution = this.#solution
98
        .replace(`<Version>${this.version}</Version>`, `<Version>${newVersion}</Version>`)
99 2
      this.version = newVersion
100
    } catch (error) {
101 2
      if (typeof error === 'string') {
102 2
        throw new Error(error)
103
      }
104
      /* istanbul ignore next */
105
      throw error
106
    }
107
  }
108
109
  /**
110
   * The list of workflows in the solution. To be able to get the list you need to run `await soFloC.load()` first.
111
   */
112 3
  get workflows () {
113 3
    if (!this.#wasLoaded) return []
114 7
    return this.#workflows.map(workflow => ({
115
      name: workflow.name,
116
      id:   workflow.id,
117
    })) as WorkflowT[]
118
  }
119
120
  /* #region LOAD METHODS */
121
  /**
122
   * Resets the loaded data
123
   */
124
  /**
125
   * Retrieves the ***Solution*** zip content
126
   * @param file The ***Solution*** zip file (base64, string, text, binarystring, array, uint8array, arraybuffer, blob or stream)
127
   */
128
  async #unzip (file: FileInput) {
129 11
    try {
130 11
      const options = typeof file === 'string'
131
        ? { base64: true }
132
        : {}
133 11
      return await JSZip.loadAsync(file, options)
134
    } catch (error) {
135 1
      console.log(error)
136 1
      throw 'Failed to unzip the file'
137
    }
138
  }
139
140
  /**
141
   * Retrieves the customization.xml string
142
   * @param zip The ***Solution*** JSZip content
143
   */
144
  async #getCustomisations (zip: JSZip): Promise<[Xml, CustomisationsXml]> {
145 10
    return (await this.#getXmlContentFromZip('customizations', zip)) as [Xml, CustomisationsXml]
146
  }
147
148
  /**
149
   * Retrieves the customization.xml string
150
   * @param zip The ***Solution*** JSZip content
151
   */
152
  async #getSolution (zip: JSZip): Promise<[Xml, SolutionXml]> {
153 9
    return (await this.#getXmlContentFromZip('solution', zip)) as [Xml, SolutionXml]
154
  }
155
156
  /**
157
   * Retrieves a XML from the ***Solution*** zip.
158
   * @param xmlName The name of the XML to be retrieved (without extension)
159
   * @returns The string content of the XML
160
   */
161
  async #getXmlContentFromZip (xmlName: string, zipContents: JSZip): Promise<[Xml, CustomisationsXml | SolutionXml]> {
162 19
    try {
163 19
      const file = zipContents.files[`${xmlName}.xml`]
164 19
      const xml = await file.async('string')
165 17
      const data = xml2js(xml, { compact: true }) as CustomisationsXml
166
167 17
      return [
168
        xml,
169
        data,
170
      ]
171
    } catch (error) {
172 2
      console.log(error)
173 2
      throw `'${xmlName}.xml' was not found in the Solution zip`
174
    }
175
  }
176
177
  /**
178
   * Retrieves the ***Solution*** current version from solution.xml
179
   * @param solution The solution.xml
180
   */
181
  #getCurrentVersion (solution: SolutionXml) {
182 8
    try {
183 8
      return solution.ImportExportXml.SolutionManifest.Version._text
184
    } catch (error) {
185 1
      console.log(error)
186 1
      throw 'Failed to retrieve the version'
187
    }
188
  }
189
190
  /**
191
   * Retrieves the list of workflows found in the ***Solution*** zip
192
   * @param customisations The customizations.xml
193
   * @param zip The ***Solution*** JSZip content
194
   * @returns The workflows list
195
   */
196
  #getWorkflows (customisations: CustomisationsXml, solution: SolutionXml, zip: JSZip) {
197 25
    const workflowFiles = Object.entries(zip.files).filter(([name]) => name.match(/Workflows\/.+\.json/)).map(file => file[1])
198
199 10
    const workflows = customisations.ImportExportXml.Workflows.Workflow
200 26
      .map(workflow => {
201 26
        const id = workflow._attributes.WorkflowId.replace(/{|}/g, '')
202 54
        const isOnSolution = solution.ImportExportXml.SolutionManifest.RootComponents.RootComponent.findIndex(wf => wf._attributes.id.includes(id)) >= 0
203 52
        const file = workflowFiles.find(workflowFile => workflowFile.name.includes(id.toUpperCase())) as JSZip.JSZipObject
204 26
        return !!file && !!id && isOnSolution
205
          ? {
206
              name: workflow._attributes.Name,
207
              id,
208
              file,
209
            }
210
          : null
211
      })
212 26
    return workflows.filter(workflow => workflow !== null) as PrivateWorkflowT[]
213
  }
214
215
  /**
216
   * Retrieves the zip data
217
   * @param zip The ***Solution*** zip
218
   * @returns The generated base64 zip
219
   */
220
  async #getData (zip: JSZip) {
221 10
    return await zip.generateAsync({
222
      type:               'base64',
223
      compression:        'DEFLATE',
224
      compressionOptions: {
225
        level: 9,
226
      },
227
    })
228
  }
229
  /* #endregion */
230
231
  /* #region COPY FLOW METHODS */
232
  /**
233
   * Verifies if a specified workflow exists in the ***Solution***
234
   */
235
  #worflowExists (flowGuid: string) {
236 7
    if (this.#workflows.findIndex(wf => wf.id === flowGuid) < 0) throw `Workflow file with GUID '${flowGuid}' does not exist in this Solution or the Solution was changed without updating 'solution.xml' or 'customizations.xml'`
237
  }
238
239
  /**
240
   * Retrieves an object containing the information of the flow copy
241
   * @param newFlowName The name of the flow copy
242
   * @returns The flow copy data
243
   */
244
  #getCopyData (newFlowName: string) {
245 3
    const guid = randomUUID()
246 3
    const upperGuid = guid.toUpperCase()
247 3
    const fileName = `Workflows/${newFlowName.replace(/\s/g, '')}-${upperGuid}.json`
248
249 3
    return {
250
      guid,
251
      upperGuid,
252
      name: newFlowName,
253
      fileName,
254
    }
255
  }
256
257
  /**
258
   * Copies the flow inside solution.xml
259
   * @param flowGuid The GUID of the original flow to be copied
260
   * @param copyData The data of the flow copy
261
   */
262
  #getUpdateSolution (flowGuid: string, copyData: FlowCopyT): [Xml, SolutionXml] {
263 3
    const rootComponent = `<RootComponent type="29" id="{${flowGuid}}" behavior="0" />`
264 3
    const rootRegEx = new RegExp(`\r?\n?.+?${rootComponent}`, 'gm')
265
266 3
    const part = this.#solution.match(rootRegEx)?.[0] as string
267
268 3
    const copy = part
269
      .replace(flowGuid, copyData.guid)
270
271 3
    const solution = this.#solution
272
      .replace(part, `${part}${copy}`)
273 3
    const data = xml2js(solution, { compact: true }) as SolutionXml
274
275 3
    return [
276
      solution,
277
      data,
278
    ]
279
  }
280
281
  /**
282
   * Copies the flow inside the customizations.xml
283
   * @param flowGuid The GUID of the original flow to be copied
284
   * @param copyData The data of the flow copy
285
   */
286
  #getUpdatedCustomisations (flowGuid: string, copyData: FlowCopyT): [Xml, CustomisationsXml] {
287 3
    const customisationsComponent = `<Workflow WorkflowId="{${flowGuid}}" Name=".+?">(.|\r|\n)+?<\/Workflow>`
288 3
    const customisationsWfRegEx = new RegExp(`\r?\n?.+?${customisationsComponent}`, 'gm')
289
290 3
    const part = this.#customisations.match(customisationsWfRegEx)?.[0] as string
291
292 3
    const jsonFileNameRegEx = /<JsonFileName>(.|\r|\n)+?<\/JsonFileName>/gi
293 3
    const introducedVersionRegEx = /<IntroducedVersion>(.|\r|\n)+?<\/IntroducedVersion>/gi
294
295 3
    const copy = part
296
      .replace(flowGuid, copyData.guid)
297
      .replace(/Name=".+?"/, `Name="${copyData.name}"`)
298
      .replace(jsonFileNameRegEx, `<JsonFileName>/${copyData.fileName}</JsonFileName>`)
299
      .replace(introducedVersionRegEx, `<IntroducedVersion>${this.version}</IntroducedVersion>`)
300
301 3
    const customisations = this.#customisations.replace(part, `${part}${copy}`)
302 3
    const data = xml2js(customisations, { compact: true }) as CustomisationsXml
303
304 3
    return [
305
      customisations,
306
      data,
307
    ]
308
  }
309
310
  /**
311
   * Copies the flow inside the ***Solution*** zip and updates data and #workflows properties
312
   * @param flowGuid The GUID of the original flow to be copied
313
   * @param copyData The data of the flow copy
314
   */
315
  async #copyFile (flowGuid: string, copyData: FlowCopyT) {
316 4
    const fileToCopy = this.#workflows.find(wf => wf.id === flowGuid.toLowerCase()) as PrivateWorkflowT
317
318 3
    this.#zip.file(copyData.fileName, await fileToCopy.file.async('string'))
319 3
    this.#zip.file('solution.xml', this.#solution)
320 3
    this.#zip.file('customizations.xml', this.#customisations)
321
322 3
    this.data = await this.#getData(this.#zip)
323 3
    this.#workflows = this.#getWorkflows(this.#customisationsData, this.#solutionData, this.#zip)
324
  }
325
  /* #endregion */
326
327
  /* #region UPDATE VERION METHODS */
328
  /**
329
   * Validates if the new version is valid
330
   * @param newVersion The new ***Solution*** version
331
   */
332
  #validateVersion (newVersion: string) {
333 4
    const validRegEx = /^((\d+\.)+\d+)$/
334 4
    if (!validRegEx.exec(newVersion)) {
335 1
      throw `Version '${newVersion}' is not valid. It should follow the format <major>.<minor>.<build>.<revision>.`
336
    }
337
338 12
    const currentVersionValues = this.version.split('.').map(value => Number(value))
339 12
    const newVersionValues = newVersion.split('.').map(value => Number(value))
340
341 3
    let currentValueString = ''
342 3
    let newValueString = ''
343 3
    for (let i = 0; i < currentVersionValues.length; i++) {
344 12
      const currentValue = currentVersionValues[i]
345 12
      const newValue = newVersionValues[i]
346
347 12
      const currentValueLength = String(currentValue).length
348 12
      const newValueLength = String(newValue).length
349
350 12
      const maxLength = Math.max(currentValueLength, newValueLength)
351
352 12
      currentValueString += '0'.repeat(maxLength - currentValueLength) + String(currentValue)
353 12
      newValueString += '0'.repeat(maxLength - newValueLength) + String(newValue)
354
    }
355
356 3
    if (Number(newValueString) <= Number(currentValueString)) throw `Version '${newVersion}' is smaller than '${this.version}'`
357
  }
358
  /* #endregion */
359
360
  /* #region  GENERAL METHODS */
361
  /**
362
   * Retrieves the version replacing '.' to '_'
363
   * @param version The version to be converted to snake_case
364
   * @returns
365
   */
366
  #snake (version: string) {
367 4
    return version.replaceAll('.', '_')
368
  }
369
  /* #endregion */
370
371
  /* #region CLASS PROPERTIES */
372
  #file: FileInput
373
  #zip: JSZip
374
  /**
375
   * The ***Solution*** file name. It is update as a new version is set
376
   */
377
  name: string
378
  /**
379
   * The ***Solution*** version. It is update as a new version is set
380
   */
381
  version: string
382
  /**
383
   * The ***Solution*** data as Base64. It is updated as new copies are added.
384
   */
385
  data: Base64
386
  #workflows: PrivateWorkflowT[]
387
  #customisations: Xml
388
  #customisationsData: CustomisationsXml
389
  #solution: Xml
390
  #solutionData: SolutionXml
391 12
  #wasLoaded = false
392
  /* #endregion */
393
}
394